Skip to content

feat(executor): opt-in per-block retry for transient failures - #6298

Draft
waleedlatif1 wants to merge 2 commits into
stagingfrom
feat/block-retry-on-fail
Draft

feat(executor): opt-in per-block retry for transient failures#6298
waleedlatif1 wants to merge 2 commits into
stagingfrom
feat/block-retry-on-fail

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a per-block retry setting (maxAttempts, waitMs) that replays the block handler when a failure looks transient. Off by default.
  • Universal by construction: the retry wraps the single handler invocation in block-executor.ts, so every block type is covered without touching any block definition — the same seam the error port uses.
  • Follows n8n's model (retryOnFail is opt-in per node) rather than Temporal's (retry-by-default), because Temporal can assume idempotent activities and we can't: replaying "post message" or "create ticket" after an ambiguous transport failure duplicates a real side effect. The builder decides.

Design notes

  • Only the handler call is retried. For a streaming block the handler returns before any token is drained, so a replay can't duplicate output the client already saw; a failure during the drain falls through untouched. Redaction/compaction are deterministic and would fail identically, so they're outside the loop.
  • Classification is structural, not string-matching: TimeoutError, socket-level codes, and transient HTTP statuses (408/429/502/503/504). A 4xx other than 408/429 is refused because it'll be rejected identically on replay. The one message-based check is Bun's dropped-connection string, which carries no code — isolated to a single named constant.
  • Cause chain is walked — providers rewrap transport failures and ProviderError overwrites name, so the classification survives only on cause. Bounded against cyclic causes.
  • Aborts never retry, including an abort wrapping a retryable cause (a block timeout that already spent its budget).
  • HITL and loop/parallel sentinels are structurally ineligible — a pause is signalled by throwing, and replaying it would re-arm rather than resume.
  • Cancellation mid-backoff stops the loop.
  • Composes with the error port: the port sees the failure only once the budget is spent, exactly as it does for a non-retrying block.
  • blockLog.attempts is set only when a block actually retried.

Type of Change

  • New feature

Testing

21 new tests across block-retry.test.ts (eligibility + classification) and block-executor.retry.test.ts (loop behaviour, ceiling, cancellation, error-port composition). Every guard was verified fail-detectable by breaking it and watching the test go red — that process caught one vacuous test of my own (a bare AbortError is unretryable anyway, so the guard needed an abort wrapping a retryable cause to be exercised).

Executor + serializer suites: 1612 passing. The one failure in executor/handlers/pi/cloud-review-tools.test.ts is pre-existing — verified by stashing this branch and reproducing it on clean origin/staging. Typecheck, lint, and check:api-validation clean.

Follow-up

No UI yet — this is the executor/serializer plumbing, so the setting is only reachable via the API/serialized workflow. A shared control alongside the existing block-level toggles is the natural next step.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 5, 2026 11:02pm

Request Review

@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core block execution and can duplicate non-idempotent side effects if retry is enabled; mitigated by opt-in config and structural transient-only classification, but still touches every block type’s handler path.

Overview
Adds opt-in per-block retry (maxAttempts, optional waitMs) on workflow blocks, serialized through the serializer and typed in @sim/workflow-types. Off by default so builders only enable it when the operation is idempotent.

BlockExecutor routes handler execution through runHandlerWithRetry, which replays only the handler call—not streaming drain, redaction, or compaction. Backoff uses backoffWithJitter; run cancellation and abortSignal stop further attempts during backoff.

block-retry.ts centralizes policy resolution (clamped bounds, exclusions for HITL and loop/parallel sentinels) and transient error detection (timeouts, socket codes, 408/429/502/503/504, Bun socket message, cause-chain walk with abort/child-workflow guards). After retries are exhausted, behavior matches a non-retrying block, including error port handling. blockLog.attempts is recorded when a block actually retried.

Reviewed by Cursor Bugbot for commit 693340f. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 693340f. Configure here.

data?: BlockData
layout?: BlockLayoutState
locked?: boolean
retry?: BlockRetryConfig

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API schema strips retry config

High Severity

retry was added to BlockState and the serializer, but workflowBlockStateSchema still omits it. Zod strips unknown keys on PUT/GET /api/workflows/[id]/state and import, so the opt-in setting cannot persist through the API path this PR relies on until UI exists.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 693340f. Configure here.

if (candidate.code && RETRYABLE_ERROR_CODES.has(candidate.code)) return true
if (typeof candidate.status === 'number' && RETRYABLE_HTTP_STATUSES.has(candidate.status)) {
return true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP status classification misses errors

High Severity

isRetryableBlockError only reads status, but generic tool failures attach Sim-owned codes as statusCode and leave upstream statuses on output. Most integration blocks therefore never retry on 408/429/502/503/504 despite opting in.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 693340f. Configure here.

delayMs,
error: normalizeError(error),
})
await sleep(delayMs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel ignored after backoff sleep

Medium Severity

Abort is checked only before sleep, and sleep is not abort-aware. A run cancelled during backoff still starts another handler attempt afterward, contrary to the mid-backoff cancellation guarantee.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 693340f. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds opt-in transient-error retries around block-handler invocation, including retry policy types, serialization, error classification, attempt logging, and executor tests.

  • Resolves bounded per-block retry policies and excludes HITL, sentinel, abort, and child-workflow failures.
  • Retries classified transport and transient HTTP failures with jittered backoff.
  • Adds retry configuration to workflow and serialized block contracts.

Confidence Score: 4/5

The cancellation and persistence failures should be fixed before merging because retries can run after cancellation and configured policies can silently disappear.

The retry loop does not recheck cancellation after its timer, while the newly supported retry field is omitted from deserialization and normalized workflow persistence.

Files Needing Attention: apps/sim/executor/execution/block-executor.ts, apps/sim/serializer/index.ts, and the normalized workflow persistence mappings

Important Files Changed

Filename Overview
apps/sim/executor/execution/block-executor.ts Adds the retry loop around handler invocation, but cancellation during backoff can still permit another attempt.
apps/sim/executor/execution/block-retry.ts Implements bounded policy resolution and structural transient-error classification with explicit ineligible cases.
apps/sim/serializer/index.ts Emits retry configuration during serialization, but the reverse and persistence round trips do not preserve it.
packages/workflow-types/src/workflow.ts Defines the shared retry policy and bounds, while exposing a field that is not yet carried through normalized persistence.
apps/sim/executor/execution/block-executor.retry.test.ts Covers retry behavior and synchronous cancellation, but not cancellation arriving during backoff.
apps/sim/executor/execution/block-retry.test.ts Thoroughly covers policy bounds, ineligible block types, transient classification, aborts, and cyclic causes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Invoke block handler] -->|Success| B[Post-process output]
  A -->|Failure| C{Retry enabled and transient?}
  C -->|No| D[Error port or throw]
  C -->|Yes| E[Backoff sleep]
  E --> F{Execution cancelled?}
  F -->|Yes| G[Stop retrying]
  F -->|No| A
Loading

Reviews (1): Last reviewed commit: "feat(executor): opt-in per-block retry f..." | Re-trigger Greptile

delayMs,
error: normalizeError(error),
})
await sleep(delayMs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cancellation bypasses retry backoff

When an execution is cancelled during the backoff sleep, the loop starts the next handler attempt without rechecking the signal, causing a side-effecting block to run again after the execution was stopped.

Suggested change
await sleep(delayMs)
await sleep(delayMs)
if (ctx.abortSignal?.aborted === true) throw error

Knowledge Base Used: Workflow Executor

@@ -327,6 +327,7 @@ export class Serializer {
color: blockConfig.bgColor,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Retry policy is not persisted

When a retry-enabled workflow is deserialized or saved and reloaded through normalized persistence, the reverse serializer and persistence mappings omit retry, causing the configured policy to disappear and later executions to invoke the block only once.

Knowledge Base Used: Workspace Frontend (Workflow Editor UI)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant